Skip to content

feat(reconcile): add a Webhook kind#1772

Merged
rohilsurana merged 18 commits into
mainfrom
feat/reconcile-webhook-kind
Jul 23, 2026
Merged

feat(reconcile): add a Webhook kind#1772
rohilsurana merged 18 commits into
mainfrom
feat/reconcile-webhook-kind

Conversation

@rohilsurana

@rohilsurana rohilsurana commented Jul 17, 2026

Copy link
Copy Markdown
Member

What

Adds a Webhook kind to the reconcile flow, so webhook endpoints are managed from a
desired-state file like platform users, permissions, roles, and preferences. Also adds the
server-side validation that lets the kind round-trip cleanly.

How the kind behaves

Follows the rules in RFC 0001. A webhook is an object keyed by its URL.

  • The URL is the identity and never changes. A missing endpoint fails the plan; deleting one
    needs delete: true.
  • Managed fields: description, subscribed events, and state. Each states the whole desired
    value under the one field model: a field written in the file is used as-is, and an omitted
    field takes its default, nothing is merged from the server.
    • subscribed_events is the full desired set: an empty or omitted list means all events (the
      server default), deduplicated and compared as a set.
    • description defaults to empty, so omitting it clears any description on the server.
    • state defaults to enabled, the state the server gives a new endpoint, so omitting it
      converges the endpoint to enabled.
  • The signing secret is server-owned. The server generates it on create and never returns it
    on read, so it is never in the file, a plan, or an export.

Files from frontier export reflect the current state and leave out any field already at its
default (an enabled state, an empty description), so reconciling an export plans nothing and
the normal edit-the-export workflow is unaffected. Only a hand-trimmed file that drops a
non-default field sees it reset to the default (dropping a disabled state re-enables the
endpoint, dropping a description clears it). This mirrors the Role kind's field model from
#1779.

Server-side validation (closes the round-trip gap)

The reconcile flow treats the URL as an endpoint's identity and manages state as
enabled/disabled. For export to round-trip over every reachable server state, the server must
not hold values the reconciler cannot represent. So the webhook service now validates on create
and update:

  • the URL is a valid absolute URL that uses http or https,
  • the state is enabled or disabled (or empty, which defaults to enabled),
  • the URL is unique across endpoints (it is the identity).

The URL is trimmed before it is validated, stored, and compared, so the server and the
reconciler agree on the same value. Invalid input returns InvalidArgument, and a duplicate
URL returns AlreadyExists. The handler maps webhook errors to status codes across create,
update, list, and delete, so for example updating a missing webhook reads as NotFound
instead of a generic internal error. (Delete stays idempotent: removing an endpoint that is
already gone reports success, which is what the reconcile flow wants.)

Uniqueness is enforced in the service. A DB unique index on webhook_endpoints.url would be a
stronger guarantee (the service check is a best-effort fast path with a clear error, and has a
narrow check-then-write race); that index is a follow-up, since it is a migration with
prod-data implications. Either way, a server that already holds duplicate or malformed rows
from before this change would need a one-time cleanup.

Changes

  • internal/reconcile/webhook.go, webhook_reconciler.go: the kind, its diff (each field
    resolved to the whole desired value over its default), export, the per-document Validate,
    and one shared step that trims, validates, and dedupes the URLs.
  • cmd/reconcile.go: register the kind and update the help text.
  • core/webhook/service.go: validate the URL (absolute, http/https), trim it, validate state,
    enforce URL uniqueness, with a service test.
  • internal/api/v1beta1connect/webhook.go: map validation errors to InvalidArgument /
    AlreadyExists / NotFound across create, update, list, and delete.
  • Docs: a Webhook section in the reconcile guide and the CLI reference kinds list.

Testing

  • go test ./internal/reconcile/... ./core/webhook/... passes, including the diff, apply,
    export round-trip, dedup, URL trimming, the http(s) scheme check, the description and state
    default-converging cases, and the server-side validation cases.
  • go build, go vet, gofmt, and golangci-lint are clean.

Not in this PR

Two SSRF and uniqueness hardening items from review are deferred on purpose:

  • A DB unique index on the webhook URL, to replace the service-level check-then-write with a
    hard constraint. It is a migration with prod-data implications, so it is a separate change.
  • Blocking internal destinations (loopback, private, link-local) at send time. This is on the
    pre-existing delivery path and is a security change of its own, best done in a dedicated PR.
    This PR does restrict the URL scheme to http(s).

@vercel

vercel Bot commented Jul 17, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Jul 23, 2026 6:44am

@coderabbitai

coderabbitai Bot commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added Webhook kind to export/reconcile workflows, including desired-state YAML, dry-run planning/apply, and exporting current webhook configuration.
  • Bug Fixes / Improvements

    • Webhook endpoint URLs are now trimmed, must be absolute http/https with a non-empty host, and must be unique.
    • Reconciliation deletion/reset semantics clarified: deletion requires delete: true; omission does not delete; omitted preferences reset to defaults (with predefined roles still protected).
    • Webhook API errors now return more precise status codes.
  • Tests

    • Added coverage for validation, URL uniqueness, diff planning, reconciliation add/update/remove, and export round-trips.
  • Documentation

    • Updated reconcile and CLI docs for Webhook kind and deletion/defaulting rules.

Walkthrough

Adds webhook endpoint validation and uniqueness checks, Connect error classification, desired-state reconciliation and export support, CLI registration, and documentation for webhook semantics.

Changes

Webhook reconciliation

Layer / File(s) Summary
Endpoint validation and API errors
core/webhook/service.go, core/webhook/service_test.go, internal/api/v1beta1connect/webhook.go
Endpoint URLs and states are validated before persistence, duplicate URLs are rejected, and webhook service errors map to specific Connect status codes.
Webhook desired-state planning
internal/reconcile/role.go, internal/reconcile/webhook.go, internal/reconcile/webhook_test.go
Webhook specs are normalized and validated, with ordered add, update, and remove operations tested across defaulting, deletion, identity, and event-set cases.
Reconciliation and export execution
internal/reconcile/webhook_reconciler.go, internal/reconcile/webhook_reconciler_test.go
Webhook list, create, update, delete, dry-run, metadata preservation, secret exclusion, and export round-trip behavior are implemented and tested.
CLI registration and documentation
cmd/reconcile.go, docs/content/docs/reconcile.mdx, docs/content/docs/reference/cli.mdx
The webhook reconciler is registered, and webhook export, deletion, omission, and desired-state rules are documented.

Estimated code review effort: 4 (Complex) | ~45 minutes

Possibly related PRs

Suggested reviewers: whoabhisheksah

🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coveralls

coveralls commented Jul 17, 2026

Copy link
Copy Markdown

Coverage Report for CI Build 29985960599

Warning

No base build found for commit c99f1f8 on main.
Coverage changes can't be calculated without a base build.
If a base build is processing, this comment will update automatically when it completes.

Coverage: 46.598%

Details

  • Patch coverage: 40 uncovered changes across 5 files (275 of 315 lines covered, 87.3%).

Uncovered Changes

File Changed Covered %
internal/reconcile/webhook_reconciler.go 113 94 83.19%
internal/api/v1beta1connect/webhook.go 14 0 0.0%
core/webhook/service.go 40 36 90.0%
internal/reconcile/webhook.go 127 125 98.43%
cmd/reconcile.go 7 6 85.71%
Total (6 files) 315 275 87.3%

Coverage Regressions

Requires a base build to compare against. How to fix this →


Coverage Stats

Coverage Status
Relevant Lines: 38843
Covered Lines: 18100
Line Coverage: 46.6%
Coverage Strength: 14.17 hits per line

💛 - Coveralls

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (2)
internal/api/v1beta1connect/webhook.go (1)

15-29: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

webhookErrCode isn't applied to ListWebhooks/DeleteWebhook.

CreateWebhook and UpdateWebhook now surface proper status codes via webhookErrCode, but ListWebhooks (line 88) and DeleteWebhook (line 108) still hardcode connect.CodeInternal. A delete of a nonexistent webhook would plausibly return webhook.ErrNotFound from the service and should map to CodeNotFound, matching this handler's own new convention.

♻️ Apply consistently
 	err := h.webhookService.DeleteEndpoint(ctx, webhookID)
 	if err != nil {
-		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
+		return nil, connect.NewError(webhookErrCode(err), fmt.Errorf("DeleteWebhook: webhook_id=%s: %w", webhookID, err))
 	}
internal/reconcile/webhook.go (1)

81-95: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Extract a shared normalize+validate+dedupe step; also trim URLs to match server normalization. validateWebhookSpec treats a whitespace-only URL as empty but then parses/stores/compares the raw untrimmed value, while core/webhook/service.go's CreateEndpoint/UpdateEndpoint always strings.TrimSpace the URL before validating/storing. A spec URL with incidental whitespace can pass client-side validation yet fail to match the server's (trimmed) URL in diffWebhooks's byURL lookup, producing a spurious "add" that the server then rejects as a duplicate. Separately, the duplicate-URL "seen"-map dedupe loop is written independently in both diffWebhooks and Validate.

  • internal/reconcile/webhook.go#L81-L95: trim s.URL (e.g. s.URL = strings.TrimSpace(s.URL)) before parsing/validating, and use the trimmed value for storage/comparison in diffWebhooks.
  • internal/reconcile/webhook_reconciler.go#L37-L53: replace the standalone validate+seen-map loop with a shared helper (e.g. normalizeAndValidateWebhookSpecs([]WebhookSpec) ([]WebhookSpec, error)) reused by diffWebhooks, so both call sites trim/validate/dedupe identically.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 5cbe9a70-6de0-457b-bfd2-93c50daea78f

📥 Commits

Reviewing files that changed from the base of the PR and between 9c2a0b0 and 45fd480.

📒 Files selected for processing (11)
  • cmd/reconcile.go
  • core/webhook/service.go
  • core/webhook/service_test.go
  • docs/content/docs/reconcile.mdx
  • docs/content/docs/reference/cli.mdx
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/role.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_reconciler_test.go
  • internal/reconcile/webhook_test.go

Comment thread core/webhook/service.go
Comment on lines +53 to +59
endpoint.URL = strings.TrimSpace(endpoint.URL)
if err := validateEndpoint(endpoint); err != nil {
return Endpoint{}, err
}
if err := s.ensureURLIsFree(ctx, endpoint.URL, endpoint.ID); err != nil {
return Endpoint{}, err
}

@coderabbitai coderabbitai Bot Jul 20, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🗄️ Data Integrity & Integration | 🔴 Critical | 🏗️ Heavy lift

TOCTOU race on URL uniqueness, plus a full table scan on every write.

ensureURLIsFree lists all endpoints and checks in Go, then Create/UpdateByID runs as a separate, unsynchronized step. Two concurrent CreateEndpoint calls for the same URL can both pass the check and both persist — exactly the "two endpoints sharing a url" scenario that internal/reconcile/webhook.go's diffWebhooks has to special-case as an unrecoverable error ("the url identity is ambiguous... delete the extra one by hand"). This check-then-act pattern is unreliable without a DB-level unique constraint or a lock/transaction spanning the check and the write.

Separately, s.eRepo.List(ctx, EndpointFilter{}) fetches every endpoint on every create/update, which won't scale as the number of webhooks grows.

Recommend enforcing uniqueness with a unique index at the storage layer (with the app-level check kept as a fast-path/better error message), and, if a targeted lookup by URL is available on the repository, using that instead of a full list scan.

Also applies to: 77-83, 108-123

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this on purpose, tracked under "Not in this PR" in the description. The real fix is a DB unique index on webhook_endpoints.url, which is a migration with prod-data implications, so it lands separately. Until then the reconcile diff fails loudly and names the duplicate ids to clean up, and the app-level check stays as a fast path with a clear error. The full-list scan on each write goes away with that same follow-up (a lookup by url).

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@rohilsurana, acknowledged. Deferring the database-level uniqueness constraint is reasonable given the migration and production-data implications. The current application-level guard plus reconcile-time duplicate detection provides a clear interim failure mode.

Would you like me to open a GitHub follow-up issue for the unique index and targeted URL lookup?


✏️ Learnings added
Learnt from: rohilsurana
Repo: raystack/frontier PR: 1772
File: core/webhook/service.go:53-59
Timestamp: 2026-07-23T06:48:13.000Z
Learning: For `core/webhook/service.go`, uniqueness of `webhook_endpoints.url` is intentionally deferred to a separate production-data-aware migration that adds a database unique index. Until then, the service-level `ensureURLIsFree` full-list check is an interim fast-path, and webhook reconciliation detects duplicate URL identities and reports the duplicate endpoint IDs for manual cleanup. A targeted repository lookup by URL is planned with that follow-up.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Comment thread core/webhook/service.go

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/webhook/service.go (1)

53-59: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Canonicalize URLs before enforcing identity uniqueness.

Only surrounding whitespace is normalized here, while ensureURLIsFree compares strings byte-for-byte. Equivalent targets such as https://EXAMPLE.com and https://example.com/ can therefore create multiple records for one webhook destination. Parse and canonicalize the URL once, persist that canonical form, and compare canonical values.

Also applies to: 77-83


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: e3aa9d73-003c-438e-8cbc-29f324acdb9c

📥 Commits

Reviewing files that changed from the base of the PR and between 45fd480 and d2832dc.

📒 Files selected for processing (6)
  • core/webhook/service.go
  • core/webhook/service_test.go
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/webhook/service_test.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_test.go

Comment thread core/webhook/service.go
Webhook description and state used keep-if-omitted: an omitted field kept the server value, so you could not clear a description or reset a disabled endpoint by leaving the field out. That breaks rule 2's one field model. resolve() now lays the file's present fields over the defaults (description empty, state enabled), so an omitted field converges to its default and nothing is merged from the server. Export already drops default-valued fields, so the round-trip still plans zero ops.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b0fc7b71-abab-40ca-b59e-084866d9f105

📥 Commits

Reviewing files that changed from the base of the PR and between d2832dc and e7a4043.

📒 Files selected for processing (11)
  • cmd/reconcile.go
  • core/webhook/service.go
  • core/webhook/service_test.go
  • docs/content/docs/reconcile.mdx
  • docs/content/docs/reference/cli.mdx
  • internal/api/v1beta1connect/webhook.go
  • internal/reconcile/role.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler.go
  • internal/reconcile/webhook_reconciler_test.go
  • internal/reconcile/webhook_test.go
🚧 Files skipped from review as they are similar to previous changes (8)
  • internal/reconcile/role.go
  • core/webhook/service_test.go
  • internal/api/v1beta1connect/webhook.go
  • docs/content/docs/reference/cli.mdx
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_reconciler_test.go
  • core/webhook/service.go
  • internal/reconcile/webhook_reconciler.go

Comment thread docs/content/docs/reconcile.mdx Outdated
…nverging

The reconcile guide still said description and state keep their server value when omitted, which the diff no longer does. Correct it to the one field model: an omitted field converges to its default (description empty, state enabled), and note export omits default-valued fields so the round-trip plans nothing.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 29b1521d-bfb2-4366-948c-21bffd0dfc70

📥 Commits

Reviewing files that changed from the base of the PR and between e7a4043 and 05a899b.

📒 Files selected for processing (1)
  • docs/content/docs/reconcile.mdx

Comment thread docs/content/docs/reconcile.mdx Outdated
url.Parse + IsAbs + a scheme check let a hostless url like https:// or http:///path through: it is absolute and uses http(s), but has no host to deliver to. Reject an empty host in both the reconcile plan-time check and the server-side validateEndpoint, so the two stay in sync and the round-trip holds.
…point

Say the url must be a valid absolute HTTP(S) url, and replace the vague "one that is missing" with "an endpoint that is missing", per review.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: ad3942bb-806d-422b-8067-4925d028b224

📥 Commits

Reviewing files that changed from the base of the PR and between 05a899b and 987cce5.

📒 Files selected for processing (5)
  • core/webhook/service.go
  • core/webhook/service_test.go
  • docs/content/docs/reconcile.mdx
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_test.go
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/webhook/service_test.go
  • core/webhook/service.go
  • internal/reconcile/webhook.go
  • internal/reconcile/webhook_test.go

Comment on lines +216 to +218
- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Document URL trimming in the identity rule.

The reconciliation contract identifies webhooks by trimmed URLs, but this section only says that the URL is the identity. State explicitly that surrounding whitespace is trimmed before validation and identity comparison.

Proposed wording
-- The URL must be a valid absolute HTTP(S) URL, and it is the identity.
+- The URL is trimmed before validation and identity comparison, and must be a valid absolute HTTP(S) URL.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
- The URL must be a valid absolute HTTP(S) URL, and it is the identity. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.
- The URL is trimmed before validation and identity comparison, and must be a valid absolute HTTP(S) URL. If two endpoints on the
server share a URL, the identity is ambiguous: the plan fails and names the ids so you can
remove the extra one by hand.

@rohilsurana
rohilsurana merged commit c770dc1 into main Jul 23, 2026
8 checks passed
@rohilsurana
rohilsurana deleted the feat/reconcile-webhook-kind branch July 23, 2026 07:45
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants